
第 22 篇解決「輸入是否合理」,這篇處理另一個問題,「誰可以做什麼」
這兩件事常常被放在一起講,但它們在框架裡是兩個不同的位置,認證是把 token 換成一個身份,授權是拿這個身份去比對路由要求的權限,分開做,兩邊都會變得比較好測
做完之後,路由層看起來會是這樣
app.routing {
get("/public") { ok("hello") }
authenticate {
get("/me") { ok(principal<UserPrincipal>().name) }
}
authorize("admin") {
get("/admin") { ok("admin panel") }
}
}
三種路由各自對應一種結果,/public 誰都能打,/me 要有效 token,/admin 除了 token 還要 admin 這個角色。handler 裡不用寫任何 if,權限檢查在進 handler 之前就結束了
跟第 22 篇的驗證一樣,這裡也是由內往外疊,每個部分都能單獨測
parseBearer() 只認得字串,把 Authorization: Bearer abc123 拆出 abc123,不碰 HTTP 也不碰 principalPrincipal 跟 principal<T>() 負責身份在 CallContext 裡的存取,只要一個 RelixCall 就測得到authenticate { },把前面兩個部分接到 pipeline 上,這個部分開始要起 appauthorize { } 的角色檢查疊在認證之上,決定 403 什麼時候出現程式碼會新增一個檔案 Auth.kt,放 parseBearer、Principal、PrincipalKey、AuthenticationConfig、Authentication 跟 auth middleware
既有的檔案改三個地方,Route 多兩個欄位,第 10 篇建立的 RoutingBuilder.kt 多 authenticate 跟 authorize 兩個函式,RelixCall.kt 多一個 principal<T>()
Route 加欄位會連帶影響到既有的呼叫點,RouteGroup.kt、RoutingBuilder.kt、RelixApplication.kt 這三個檔案裡建 Route 的地方要換成具名參數,RouterTest.kt 跟 PathParamTest.kt 也有少數幾個測試要跟著改,理由在第三個部分說明
測試四個部分各一個檔案,BearerParserTest.kt、PrincipalTest.kt、AuthenticationTest.kt、AuthorizationTest.kt
最裡面這個部分就是字串進、字串出,token 有效不有效它不管,那是下一個部分的事,不需要 app、不需要 request,直接呼叫,測試檔案放 BearerParserTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class BearerParserTest {
@Test
fun `parses a normal bearer header`() {
assertEquals("abc123", parseBearer("Bearer abc123"))
}
@Test
fun `scheme is case insensitive`() {
assertEquals("abc123", parseBearer("bearer abc123"))
}
@Test
fun `trims surrounding whitespace from the token`() {
assertEquals("abc123", parseBearer("Bearer abc123 "))
}
@Test
fun `returns null when the header is absent`() {
assertNull(parseBearer(null))
}
@Test
fun `returns null for a different scheme`() {
assertNull(parseBearer("Basic dXNlcjpwYXNz"))
}
@Test
fun `returns null when the token is empty`() {
assertNull(parseBearer("Bearer "))
}
@Test
fun `returns null when the token is whitespace only`() {
assertNull(parseBearer("Bearer "))
}
}
七個測試,一個成功四個 null,回 null 的四種情況要分開測,因為它們的成因不一樣,沒有 header、換了別的 scheme、token 是空的、token 只有空白,任何一種漏掉,後面 middleware 就會拿到一個看起來像 token 的空字串
大小寫那個測試是照著 RFC 7235 Section 2.1 寫的,auth scheme 規定是 case-insensitive,有些 client library 送出來的是 bearer
新增 Auth.kt
fun parseBearer(header: String?): String? {
if (header == null) {
return null
}
val prefix = "Bearer "
if (!header.startsWith(prefix, ignoreCase = true)) {
return null
}
val token = header.substring(prefix.length).trim()
return token.ifEmpty { null }
}
trim() 之後接 ifEmpty { null },把「只有空白」跟「完全沒有」變成同一個結果,呼叫端只要判斷 null 就好,不用再多想一種空字串的狀況。七個測試到這裡全部通過
第二個部分要回答的是,身份放進 CallContext 之後怎麼拿回來,以及拿不到的時候會發生什麼事
這個部分還是不用起 app,RelixCall 建出來就能用,測試檔案放 PrincipalTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
data class ServicePrincipal(val clientId: String) : Principal
class PrincipalTest {
private fun emptyCall(): RelixCall {
val app = RelixApplication()
val request = RelixRequest(
method = "GET",
path = "/me",
headers = emptyMap(),
queryParameters = emptyMap(),
)
return RelixCall(app, request)
}
@Test
fun `principal returns the value put into the context`() {
val call = emptyCall()
call.context[PrincipalKey] = UserPrincipal("1", "Alice")
val user = call.principal<UserPrincipal>()
assertEquals("1", user.userId)
assertEquals("Alice", user.name)
}
@Test
fun `principal throws 401 when the context is empty`() {
val call = emptyCall()
val e = assertFailsWith<RelixHttpException> { call.principal<UserPrincipal>() }
assertEquals(401, e.statusCode)
}
@Test
fun `principal throws 500 when the type does not match`() {
val call = emptyCall()
call.context[PrincipalKey] = ServicePrincipal("svc-1")
val e = assertFailsWith<RelixHttpException> { call.principal<UserPrincipal>() }
assertEquals(500, e.statusCode)
}
}
UserPrincipal 是應用層的身份型別,ServicePrincipal 則只有第三個測試用得到,宣告在測試檔裡就好
emptyCall() 把 headers 跟 queryParameters 明確填成 emptyMap(),第 4 篇的 RelixRequest 這兩個欄位沒有預設值,只有 body 有。這個部分根本不看 header,補這兩個空 map 純粹是為了讓建構子過得去,三個測試共用同一個 helper 就不用各寫一次
401 跟 500 這兩個測試分開的理由是成因不同,context 是空的表示認證沒發生過,這是 client 的問題,型別對不上表示 middleware 塞進去的東西跟 handler 要的不是同一種,這是框架或設定的問題,不該讓 client 看到 401 然後以為換個 token 就會好
Principal 只有一行,是個 marker interface,一樣放進 Auth.kt
interface Principal
具體的身份由應用層自己定義
data class UserPrincipal(
val userId: String,
val name: String,
val roles: Set<String> = emptySet(),
) : Principal
為什麼不在框架裡定義 UserPrincipal ? 因為每個應用的使用者模型不一樣,有些只需要 userId,有些需要角色清單、權限列表、組織 ID,框架提供介面,應用填入內容,roles 這個欄位到第四個部分才會用到,先放在這裡
接著是 typed key 一樣放在 Auth.kt 裡面
val PrincipalKey = CallContext.Key<Principal>("principal")
取值函式則放在 RelixCall.kt 裡面
class RelixCall(
val application: RelixApplication,
val request: RelixRequest,
internal var pathParams: Map<String, String> = emptyMap(),
internal var matchedRoute: Route? = null,
) {
inline fun <reified T : Principal> principal(): T {
val p = context[PrincipalKey]
?: throw RelixHttpException(401, "Not authenticated")
return p as? T
?: throw RelixHttpException(500, "Principal type mismatch: expected ${T::class.simpleName}")
}
// ...
}
PrincipalKey 是第 12 篇介紹的 CallContext.Key<T>,它保證 key 名稱不會拼錯,也保證拿回來的一定是 Principal,但保證不到那是你要的哪一種 Principal,把 T 檢查出來的是簽名上的 reified,compiler 會把實際型別 inline 到呼叫點,p as? T 才是真的 runtime 檢查,型別不符才回得了 500
前面兩個部分都沒碰到 HTTP,plugin 還沒裝、middleware 也還沒寫,這輪要測的正是這段接線,plugin 有裝、路由標記得到、401 有沒有真的出去,所以回到 TestKit,測試檔案放 AuthenticationTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AuthenticationTest {
private fun createApp(): RelixApplication {
val app = RelixApplication()
app.install(ErrorHandling)
app.install(Authentication) {
bearer { token ->
when (token) {
"token-alice" -> UserPrincipal("1", "Alice")
else -> null
}
}
}
app.routing {
get("/public") { ok("hello") }
authenticate {
get("/me") { ok(principal<UserPrincipal>().name) }
}
}
return app
}
@Test
fun `public route needs no token`() {
val response = RelixTestKit(createApp()).handleRequest("GET", "/public")
assertEquals(200, response.statusCode)
assertEquals("hello", response.body.toString(Charsets.UTF_8))
}
@Test
fun `protected route without a token returns 401`() {
val response = RelixTestKit(createApp()).handleRequest("GET", "/me")
assertEquals(401, response.statusCode)
assertTrue(response.headers["WWW-Authenticate"]?.contains("Bearer") == true)
}
@Test
fun `protected route with an invalid token returns 401`() {
val response = RelixTestKit(createApp()).handleRequest(
method = "GET",
path = "/me",
headers = mapOf("Authorization" to listOf("Bearer bad-token")),
)
assertEquals(401, response.statusCode)
}
@Test
fun `protected route with a valid token sees the principal`() {
val response = RelixTestKit(createApp()).handleRequest(
method = "GET",
path = "/me",
headers = mapOf("Authorization" to listOf("Bearer token-alice")),
)
assertEquals(200, response.statusCode)
assertEquals("Alice", response.body.toString(Charsets.UTF_8))
}
}
createApp() 裡那行 install(ErrorHandling) 跟第 21、22 篇是同一個理由,principal<T>() 用 throw 表達錯誤,丟的是第 16 篇那個 RelixHttpException,沒有 middleware 在外面接住,exception 會一路穿出去,測試拿到的不是 500 而是直接失敗
沒帶 token 那個測試多斷言了一個 WWW-Authenticate header,401 只寫 status code 是不夠的,RFC 7235 規定 401 必須告訴 client 該用哪種方式認證,少了這個 header,client 只知道被拒絕,不知道下一步要做什麼
先看 config 跟 plugin 本體,一樣放到 Auth.kt
class AuthenticationConfig {
internal var bearerValidator: ((String) -> Principal?)? = null
fun bearer(validate: (String) -> Principal?) {
bearerValidator = validate
}
}
object Authentication : RelixPlugin<AuthenticationConfig> {
override fun createDefaultConfig() = AuthenticationConfig()
override fun install(
application: RelixApplication,
config: AuthenticationConfig,
) {
application.use(authMiddleware(config))
}
}
Authentication 是一個 RelixPlugin<AuthenticationConfig>,跟第 18 篇的 Plugin 系統接起來,驗證函式的簽名是 (String) -> Principal?,給 token 字串,回 Principal 表示成功,回 null 表示失敗,不丟 exception,null 就夠了
路由這邊要有辦法標記「這條需要認證」,Route 加一個旗標
data class Route(
val method: String,
val path: String,
val requiresAuth: Boolean = false,
val handler: RelixHandler,
)
新欄位插在 handler 前面,這個位置不能隨便放,第 8、9 篇的 RouterTest 跟 PathParamTest 是這樣建 route 的
val route = Route("GET", "/users/{id}") { ok("user") }
trailing lambda 語法綁的是最後一個參數,handler 排在最後,那個 lambda 才會落到它身上,把 requiresAuth 加在 handler 後面,lambda 就變成要傳給 Boolean,那兩個測試檔會冒出一整排 Argument type mismatch: actual type is '() -> RelixResponse', but 'Boolean' was expected,前面幾篇累積的路由測試全部編譯不過
改完欄位順序,換另一批呼叫會抱怨,src/ 底下有三個檔案是用位置參數建 Route 的,第 11 篇的 RouteGroup.kt 八處,第 10 篇的 RoutingBuilder.kt 五處,RelixApplication.kt 四處,長相都像這樣
routes += Route("GET", normalizePath(prefix, path), handler)
第三個位置現在是 requiresAuth,handler 就傳錯格了,這十七處全部要改成具名參數
routes += Route(
method = "GET",
path = normalizePath(prefix, path),
handler = handler,
)
測試那邊也有躲不掉的,RouterTest.kt 的 HEAD fallback 跟 PathParamTest.kt 的靜態路由優先都要動,凡是要斷言「匹配到的是哪一個 handler」,就得先把 handler 宣告成變數才有東西可以比對,這種寫法用不了 trailing lambda
val headHandler: RelixHandler = { ok("head-specific") }
router.add(Route("HEAD", "/hello", handler = headHandler))
router.add(Route("GET", "/hello") { ok("get-handler") })
第一個 add 把 handler 具名傳進去,第二個維持 trailing lambda,兩種混用沒問題,Kotlin 只要求具名參數排在位置參數後面。這兩個檔案裡其他只是註冊路由、不比對 handler 的測試都是 trailing lambda,不受影響
整理起來就是,位置參數等於把呼叫端綁在 data class 的欄位順序上,欄位一多就是這種結果,改成具名之後,Route 以後再加欄位,這些呼叫點都不用再動,只有 trailing lambda 那種寫法是靠 handler 排在最後撐著
然後在 RoutingBuilder 加上 authenticate
fun authenticate(block: RoutingBuilder.() -> Unit) {
val innerBuilder = RoutingBuilder()
innerBuilder.block()
for (route in innerBuilder.build()) {
this.routes.add(route.copy(requiresAuth = true))
}
}
authenticate { } 建立一個內部的 RoutingBuilder,在裡面註冊的路由都會被標記 requiresAuth = true,然後合併到外層的 routes 裡,預設是 false,只有在 authenticate { } 裡面註冊的才是 true
middleware 要知道目前打到哪條 route 才知道要不要檢查,這個欄位第 12 篇定義 RelixCall 的時候就備好了,internal var matchedRoute: Route? = null,第 13 篇的匹配階段會把 call.matchedRoute = result.route 寫進去,接著才執行 pipeline,整段流程用的是同一個 call,所以 middleware 直接讀得到
下面的程式一樣都放在 Auth.kt
fun authMiddleware(config: AuthenticationConfig): RelixMiddleware {
return { next ->
checkAuth(config) ?: next()
}
}
internal fun RelixCall.checkAuth(config: AuthenticationConfig): RelixResponse? {
val route = matchedRoute ?: return null
if (!route.requiresAuth) return null
val token = parseBearer(request.header("Authorization")) ?: return unauthorized()
val principal = config.bearerValidator?.invoke(token) ?: return unauthorized()
context[PrincipalKey] = principal
return null
}
internal fun unauthorized() = RelixResponse(
401,
mapOf(
"WWW-Authenticate" to listOf("Bearer"),
"Content-Type" to listOf("text/plain; charset=utf-8"),
),
"Unauthorized".toByteArray(),
)
checkAuth() 的回傳型別是 RelixResponse?,null 表示放行,非 null 就是要直接回給 client 的拒絕回應。middleware 本體因此只剩一行,checkAuth(config) ?: next()
拆成兩個函式是為了那一連串 early return,認證這件事本質上是「任何一關沒過就馬上結束」,用 ?: 加 return 寫出來,每一行都是一個獨立的關卡,全部擠進 middleware 的 lambda 會變成四層巢狀的 if/else,多一個檢查就再深一層,lambda 裡沒辦法 early return,這是拆出來的直接理由
認證做完了,/me 已經知道打進來的是誰,這一輪要處理的是「知道你是誰之後,要不要給你進」
測試檔案放 AuthorizationTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class AuthorizationTest {
private fun createApp(): RelixApplication {
val app = RelixApplication()
app.install(ErrorHandling)
app.install(Authentication) {
bearer { token ->
when (token) {
"token-alice" -> UserPrincipal("1", "Alice", setOf("admin"))
"token-bob" -> UserPrincipal("2", "Bob", setOf("member"))
"token-carol" -> UserPrincipal("3", "Carol", setOf("editor"))
else -> null
}
}
roles { principal -> (principal as UserPrincipal).roles }
}
app.routing {
authenticate {
get("/me") { ok(principal<UserPrincipal>().name) }
}
authorize("admin") {
get("/admin") { ok("admin panel") }
}
authorize("admin", "editor") {
get("/posts") { ok("post editor") }
}
}
return app
}
@Test
fun `authorize implies authentication`() {
val response = RelixTestKit(createApp()).handleRequest("GET", "/admin")
assertEquals(401, response.statusCode)
}
@Test
fun `a user with the required role passes`() {
val response = RelixTestKit(createApp()).handleRequest(
method = "GET",
path = "/admin",
headers = mapOf("Authorization" to listOf("Bearer token-alice")),
)
assertEquals(200, response.statusCode)
assertEquals("admin panel", response.body.toString(Charsets.UTF_8))
}
@Test
fun `a user without the required role gets 403`() {
val response = RelixTestKit(createApp()).handleRequest(
method = "GET",
path = "/admin",
headers = mapOf("Authorization" to listOf("Bearer token-bob")),
)
assertEquals(403, response.statusCode)
assertEquals("Forbidden", response.body.toString(Charsets.UTF_8))
}
@Test
fun `any one of the listed roles is enough`() {
val testKit = RelixTestKit(createApp())
val byAdmin = testKit.handleRequest(
method = "GET",
path = "/posts",
headers = mapOf("Authorization" to listOf("Bearer token-alice")),
)
val byEditor = testKit.handleRequest(
method = "GET",
path = "/posts",
headers = mapOf("Authorization" to listOf("Bearer token-carol")),
)
assertEquals(200, byAdmin.statusCode)
assertEquals(200, byEditor.statusCode)
}
@Test
fun `routes without authorize are unaffected by roles`() {
val response = RelixTestKit(createApp()).handleRequest(
method = "GET",
path = "/me",
headers = mapOf("Authorization" to listOf("Bearer token-bob")),
)
assertEquals(200, response.statusCode)
assertEquals("Bob", response.body.toString(Charsets.UTF_8))
}
@Test
fun `authorize without a roles extractor fails loudly`() {
val app = RelixApplication()
app.install(Authentication) {
bearer { UserPrincipal("1", "Alice", setOf("admin")) }
}
app.routing {
authorize("admin") {
get("/admin") { ok("admin panel") }
}
}
assertFailsWith<IllegalStateException> {
RelixTestKit(app).handleRequest(
method = "GET",
path = "/admin",
headers = mapOf("Authorization" to listOf("Bearer any")),
)
}
}
}
第一個測試確認 authorize 包含了 authenticate,沒帶 token 打 /admin 拿到的是 401 不是 403,順序不能反過來,先確定你是誰,才談你能不能做
第二個跟第三個是這一輪的主體,同一個路徑、同樣有效的 token,Alice 進得去 Bob 進不去,差別只在角色
第四個測試釘的是多個角色的語意,authorize("admin", "editor") 是「其中一個就夠」,不是「兩個都要有」,Alice 只有 admin、Carol 只有 editor,兩個都該進得去
第五個測試確認 authenticate { } 不會被角色檢查波及,Bob 沒有 admin,但 /me 本來就沒要求角色,他該拿到 200
最後一個測試比較特別,它斷言的是框架使用者寫錯的時候會爆,用了 authorize("admin") 卻沒設定 roles { },框架根本不知道去哪裡拿角色,這種情況安靜回 403 是最糟的選擇,你會以為權限設錯,翻半天才發現是少寫一段設定,所以這裡直接丟 IllegalStateException
Route 再多一個欄位,一樣插在 handler 前面
data class Route(
val method: String,
val path: String,
val requiresAuth: Boolean = false,
val requiredRoles: Set<String> = emptySet(),
val handler: RelixHandler,
)
RoutingBuilder 的 authorize 跟 authenticate 是同一個手法,差別在它同時設定兩個欄位
fun authorize(vararg roles: String, block: RoutingBuilder.() -> Unit) {
val innerBuilder = RoutingBuilder()
innerBuilder.block()
for (route in innerBuilder.build()) {
val inherited = route.requiredRoles.ifEmpty { roles.toSet() }
this.routes.add(
route.copy(requiresAuth = true, requiredRoles = inherited),
)
}
}
requiresAuth = true 一起設定,這就是「authorize 包含 authenticate」在程式碼裡的樣子,使用者不用把兩層疊起來寫
inherited 那三行處理的是巢狀。內層已經有角色要求時保留內層的,不跟外層做聯集。因為這裡的多角色是 OR,一聯集,authorize("admin") { authorize("editor") { } } 會變成 admin 或 editor 都能進,巢狀反而放寬了限制,跟直覺相反。讓內層優先,巢狀的語意就是「以最靠近路由的那個宣告為準」
config 加上角色的取得方式
class AuthenticationConfig {
internal var bearerValidator: ((String) -> Principal?)? = null
internal var roleExtractor: ((Principal) -> Set<String>)? = null
fun bearer(validate: (String) -> Principal?) {
bearerValidator = validate
}
fun roles(extract: (Principal) -> Set<String>) {
roleExtractor = extract
}
}
Principal 仍然是那個空介面,框架不規定角色放在哪個欄位、叫什麼名字,甚至不規定角色一定要存在 principal 裡,roles { } 拿到 principal 之後去查資料庫也是合法的
最後把角色檢查接到 checkAuth() 後半段
internal fun RelixCall.checkAuth(config: AuthenticationConfig): RelixResponse? {
val route = matchedRoute ?: return null
if (!route.requiresAuth) {
return null
}
val token = parseBearer(request.header("Authorization")) ?: return unauthorized()
val principal = config.bearerValidator?.invoke(token) ?: return unauthorized()
context[PrincipalKey] = principal
if (route.requiredRoles.isEmpty()) {
return null
}
val extract = config.roleExtractor
?: error("authorize() needs a roles { } extractor in the Authentication config")
val granted = extract(principal)
return if (route.requiredRoles.any { it in granted }) null else forbidden()
}
internal fun forbidden() = RelixResponse(
403,
mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
"Forbidden".toByteArray(),
)
新增的部分接在 context[PrincipalKey] = principal 後面,順序是刻意的,角色不過的時候 principal 已經放進 context 了,後面的 middleware 或錯誤處理如果想知道「是誰被擋下來」,拿得到人
route.requiredRoles.any { it in granted } 就是 OR 的實作,要改成 AND 只要換成 granted.containsAll(route.requiredRoles),一行的差別,但 API 的語意會完全不同,這種決定要在寫測試的時候就想清楚
error() 丟的是 IllegalStateException,不是 RelixHttpException,這個區別是故意的,RelixHttpException 會被 ErrorHandling 翻成 HTTP 回應,但設定漏寫不是 client 的錯,不該變成一個 5xx 回應然後被當成偶發錯誤忽略掉,它應該在開發階段第一次打這條路由就炸開
| Status | 意思 | 誰負責 | 回應 header |
|---|---|---|---|
| 401 | 沒有認證或認證無效 | Auth middleware | WWW-Authenticate: Bearer |
| 403 | 已認證但權限不足 | Auth middleware | 無特殊 header |
401 表示「再試一次,帶上正確的 token」,403 表示「你的 token 沒問題,但你就是不能做這件事」
常見錯誤是把「沒帶 token」當 403,RFC 7235 很明確,沒認證是 401,不是 403,403 暗示 server 知道你是誰 (已認證),但不給你進
UserPrincipal 是應用層的型別,前面為了測試方便寫在 test/ 底下,這裡要搬到 src/,main() 才用得到
fun main() {
val app = RelixApplication()
app.install(Logging) {
logger = ConsoleLogger()
}
app.install(ErrorHandling)
app.install(ContentNegotiation) {
json()
}
app.install(Authentication) {
bearer { token ->
when (token) {
"token-alice" -> UserPrincipal("1", "Alice", setOf("admin"))
"token-bob" -> UserPrincipal("2", "Bob", setOf("member"))
else -> null
}
}
roles { principal -> (principal as UserPrincipal).roles }
}
app.routing {
get("/public") { ok("hello") }
authenticate {
get("/me") { ok(principal<UserPrincipal>().name) }
}
authorize("admin") {
get("/admin") { ok("admin panel") }
}
}
JdkHttpServerAdapter(app).start(8080)
}
bearer { } 裡的 token 是寫死的,正式環境這裡會去查資料庫或驗 JWT 簽章,但 middleware 那一層不用知道差別,它只要拿到 Principal 或 null
公開路由,不帶 token
curl -i localhost:8080/public
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 5
hello
/public 沒有寫在 authenticate { } 或 authorize { } 裡面,requiresAuth 是 false,middleware 第二行就放行了
保護路由,不帶 token
curl -i localhost:8080/me
HTTP/1.1 401 Unauthorized
Www-authenticate: Bearer
Content-type: text/plain; charset=utf-8
Content-length: 12
Unauthorized
header 名稱印出來是 Www-authenticate 不是我們寫的 WWW-Authenticate,這是 JDK HttpServer 在 Headers 這個容器裡做的正規化,它會把每個 header 名稱統一成首字母大寫,HTTP header 名稱本來就是 case-insensitive,client 不會因此認不得
帶有效 token
curl -i -H "Authorization: Bearer token-alice" localhost:8080/me
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 5
Alice
帶有效 token,但角色不對
curl -i -H "Authorization: Bearer token-bob" localhost:8080/admin
HTTP/1.1 403 Forbidden
Content-type: text/plain; charset=utf-8
Content-length: 9
Forbidden
換成有 admin 角色的 token
curl -i -H "Authorization: Bearer token-alice" localhost:8080/admin
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 11
admin panel
後面這兩組的對比就是 401 跟 403 的差別,Bob 的 token 完全有效,middleware 也認得他,是角色不符才被擋在 handler 之外,/admin 的 handler 從頭到尾沒有執行過
log 五筆
[Relix] GET /public -> 200 (0ms)
[Relix] GET /me -> 401 (0ms)
[Relix] GET /me -> 200 (3ms)
[Relix] GET /admin -> 403 (0ms)
[Relix] GET /admin -> 200 (0ms)
401 跟 403 那兩筆都是 auth middleware 短路掉的,request 沒有進到 handler,logging 一樣記得到,因為 Logging 裝在 Authentication 前面,它包在整條 pipeline 的最外層
第三筆的 3ms 是 JVM 的暖機,/me 這一筆是整輪裡第一個真的走完 handler 的 request,principal<UserPrincipal>() 跟 ok() 都是第一次執行,還沒被 JIT 編譯,後面那兩筆走同一段路就回到 0ms 了
Ktor 自己有 role-based authorization 嗎 ?
沒有,Ktor 的 auth plugin 給的是 authenticate("name") 這種具名 provider,那仍然屬於認證,選的是「用哪一套方式驗身份」,角色檢查在 Ktor 官方文件裡的做法是自己在 route 裡面 intercept,或是搭配 StatusPages 定義自己的 AuthorizationException 再翻成 403
也就是說,這篇的 authorize { } 是 Relix 比 Ktor 多走的一步,真實框架不做的理由通常是權限模型差異太大,RBAC、ABAC、資源層級的 ownership 檢查,很難用一組 API 全部涵蓋,Relix 這裡選了最單純的 RBAC,換來的是路由層看得到權限宣告
為什麼 403 從 handler 移到 middleware ?
宣告在路由上的權限,讀路由就看得完,權限判斷留在 handler 裡的時候,你得打開每一個 handler 才知道誰能打,而且很容易漏掉一個,漏掉的那個就是安全漏洞
但這不表示 handler 裡不再需要任何權限判斷,/users/{id} 只允許本人存取這種規則,跟角色無關,它要比對的是 path parameter 跟 principal 的 userId,這種 middleware 幫不上忙,還是得在 handler 裡寫,角色進 middleware,資源層級的 ownership 留在 handler,這條線大致就是這樣分
拿 Spring Security 來對照,它把這個分工做成兩個獨立元件,AuthenticationEntryPoint 處理「沒身份」 (401,引導 client 去登入或補 token),AccessDeniedHandler 處理「有身份但沒權限」 (403,無論補什麼 token 都不會通過)
為什麼不把 token 驗證做成 suspend ?
這裡的 bearer validator 是 (String) -> Principal? 同步函式,真實場景可能需要查資料庫或打外部 API,第 14 篇升級 pipeline 成 suspend 之後,validator 也可以改成 suspend (String) -> Principal?,roles { } 也一樣。不過這裡先使用同步,降低認知負擔
角色用字串會不會太鬆 ?
會,authorize("admni") 這種 typo compiler 完全攔不住,要到 runtime 才會發現所有人都被擋在外面,做嚴一點的話可以換成 enum 或 sealed class,代價是框架得先知道應用有哪些角色,或是多一層泛型,這跟第 22 篇 matches(regex) 把 pattern 直接放進錯誤訊息是同一類取捨,這裡先使用字串
認證跟授權在 Relix 裡是同一個 middleware 的前後兩段,前段把 Bearer token 換成 Principal 放進 CallContext,失敗回 401,後段拿 Principal 的角色比對路由的 requiredRoles,不符回 403
路由層看得到兩個標記,authenticate { } 要求有效身份,authorize("admin") { } 在此之上再要求角色,Principal 維持成一個空介面,角色怎麼存、從哪裡拿,由應用層的 roles { } 決定
下一篇會做 Static File Serving,看起來簡單,但會碰到 MIME type 判斷、ETag/304 快取、path traversal 安全防護
同步刊登於 Blog
圖片來源:AI 產生